#!/usr/bin/env python
#Name: BrownieHelper
#Developed by: Sonora (The Dragonflight)
#Website: http://sonora.kirintor.googlepages.com/contents
#SVN: http://code.google.com/p/browniehelper/source/browse
#License: GNU General Public License v3

"""
Helper script that works in tandem with the BrownieHelper WoW addon.

Right now our lua 'parser' only handles a single top-level table, but it should
be sufficient to handle any data saved in the SavedVariables file by Wow.

This is a barebones sort of a module -- very little polish or intelligent error
handling.

To get command line options run: python browniehelper.py --help
"""

__version__ = "1.5"

from sgmllib import SGMLParser
from optparse import OptionParser
from ConfigParser import ConfigParser
import htmlentitydefs, urllib, re, os, sys, datetime

#Useful for debugging
mod_name = "BrownieHelper"
db_name = "BrownieHelperDB2"

#Lua parsing
comments_short = re.compile(r'--.*\n')
comments_long = re.compile(r'--\[\[.*\]\]', re.DOTALL)

find_string = re.compile(r'(?P<quote>[\'"])(?P<val>.*?)(?P=quote)')
find_bracket_string = re.compile(r'\[(?P<quote>[\'"])(?P<val>.*?)(?P=quote)\]')
find_number = re.compile(r'[^A-Za-z](?P<val>-?\d+\.\d+|-?\d+)')
find_boolean = re.compile(r'(^|\s+)(?P<val>true|false)')
find_nil = re.compile(r'(^|\s+)(?P<val>nil)')
find_table_start = re.compile(r'\s*{\s*')
find_table_end = re.compile(r'\s*}\s*')
find_assignment = re.compile(r'\s*=\s*')
find_elementsep = re.compile(r'\s*,\s*')

class LuaParsingError(Exception): pass
class LuaString(str): pass
class TableStart(object): pass
class TableEnd(object): pass
class Assignment(object): pass
class ElementSep(object): pass
class LuaParser(object):

    parseTypes = [
        (find_bracket_string, lambda match: match.groupdict()['val']),
        (find_string, lambda match: match.groupdict()['val']),
        (find_number, lambda match: ("." in match.groupdict()['val'] and float(match.groupdict()['val']) or int(match.groupdict()['val']))),
        (find_boolean, lambda match: (match.groupdict()['val']=="true" and True or False)),
        (find_nil, lambda match: None),
        (find_table_start, lambda match: TableStart),
        (find_table_end, lambda match: TableEnd),
        (find_assignment, lambda match: Assignment),
        (find_elementsep, lambda match: ElementSep),
    ]
    
    def __init__(self, seedtext):
        self.globalTable = {}
        self.parse(seedtext)
    
    def cleanComments(self, txt): return comments_short.sub("", comments_long.sub("", txt))
    def parseTableContents(self, sequence):
        
        #If this is an assingment table
        if sequence==[]: return {}
        elif sequence[1] == Assignment: return self.parseAsDict(sequence)
        else: return self.parseAsList(sequence)
        
    def parseAsDict(self, sequence):
        
        tbl = {}
            
        while sequence:
            if sequence[1]!=Assignment:
                raise LuaParsingError, "Assignment expected but not found at element '" + str(sequence[1]) + "'.  Unable to parse Lua."
            if len(sequence)<3: raise LuaParsingError, "Assignment has no operand."
            
            if type(sequence[0]) == LuaString: key = str(sequence[0]).strip()
            else: key = sequence[0]
            
            val, sequence = self.getNextValue(sequence[2:])
            tbl[key] = val
            
        return tbl
        
    def parseAsList(self, sequence):
        
        tbl = []
        
        while sequence:
            val, sequence = self.getNextValue(sequence)
            tbl.append(val)
            
        return tbl
        
    def getNextValue(self, sequence):
        
        if type(sequence[0]) == LuaString:
            val = str(sequence[0])
            remainder = sequence[1:]
        elif sequence[0] == TableStart:
            tblSequence, remainder = self.getTableRange(sequence)
            val = self.parseTableContents(tblSequence)
        else:
            val = sequence[0]
            remainder = sequence[1:]
        
        if len(remainder)>1 and remainder[0]!=ElementSep: raise LuaParsingError, "Element separator expected."
        
        return val, remainder[1:]
        
    def getTableRange(self, sequence):
        
        if sequence[0]!=TableStart: raise LuaParsingError, "Invalid table sequence, unable to parse lua."
        
        counter = 0
        for pos, el in enumerate(sequence):
            if el==TableStart: counter += 1
            if el==TableEnd: counter -=1
            if counter==0:
                return sequence[1:pos], sequence[pos+1:]
        
    def parse(self, text):
        
        source = self.cleanComments(text)
        
        #Construct a line of types and conjoining text
        sequence = [LuaString(source),]
        
        #For each type parser, pull out the recognized type   
        for parser, handler in self.parseTypes:
            
            #For each text node in sequence
            expandedSequence = []
            for element in sequence:
                for newElement in self.parseElement(element, parser, handler):
                    expandedSequence.append(newElement)
                    pass
                
            sequence = expandedSequence
            
        #Assign the data described by the sequence to the global table
        self.globalTable = self.parseTableContents(sequence)
        
    def parseElement(self, element, parser, handler):
        
        #If this is a LuaString, attempt to parse
        if type(element)==LuaString:
            
            if parser.search(element):
                
                lastPos = 0
                
                #find all non-overlapping type constructs in txt
                for match in parser.finditer(element):
                    
                    val = handler(match)
                    
                    if match.start()> lastPos: yield LuaString(element[lastPos:match.start()])
                    yield val
                    
                    lastPos = match.end()
                    
                if lastPos!=len(element): yield LuaString(element[lastPos:])
                
            else: yield element
                
        #Otherwise just yield it
        else: yield element
        

#Lua pp'ing
class luaPrint(object):
    
    #Converts dicts/lists to lua tables    
    def convertValue(self, val):
        if type(val)==dict: return LuaTable(val)
        if type(val)==list: return LuaList(val)
        if type(val)==str: return val.replace("\n",r'\n')
        else: return val
        
    def __str__(self): return self.pp()

class LuaTable(luaPrint, dict):
    def __init__(self, seed=None):
        """
        Seed should be a dict.
        """
        if seed:
            for k,v in seed.items(): self.addItem(k,v)
            
    #pp'er
    def pp(self, indentation=""):
        ret = "{\n"
        
        local_indent = indentation + "\t"
        
        for k,v in self.items():
            ret += local_indent + '["' + k + '"] = '
            
            if type(v)==LuaTable or type(v)==LuaList: ret+= v.pp(local_indent)
            elif type(v) == str: ret += '"' + v + '",\n'
            elif type(v) == list: ret += '"' + ", ".join(v) + '",\n'
            else: ret += str(v).replace("False",'false').replace("True",'true') + ",\n"
            
        if indentation!="": return ret + indentation + "},\n"
        else: return ret + indentation + "}\n"
    
    #Converts dicts to lua tables    
    def addItem(self, key, val): self[key]=self.convertValue(val)

class LuaList(luaPrint, list): #models a Lua table with just values (printed to saved variables as "val", -- n)
    def __init__(self, seed=None):
        """
        Seed should be a dict.
        """
        if seed:
            for v in seed: self.addItem(v)
            
    def addItem(self, val): self.append(self.convertValue(val))
    
    #pp'er
    def pp(self, indentation=""):
        ret = "{\n"
        
        local_indent = indentation + "\t"
        
        for i,v in enumerate(self):
            ret += local_indent
            
            if type(v)==LuaTable or type(v)==LuaList: ret+= v.pp(local_indent)
            elif type(v) == str: ret += '"' + v + '"'
            elif type(v) == list: ret += '"' + ", ".join(v) + '",\n'
            else: ret += str(v).replace("False",'false').replace("True",'true')
            
            ret += ", -- [" + str(i+1) + "]\n"
            
        if indentation!="": return ret + indentation + "},\n"
        else: return ret + indentation + "}\n"


#HTML parsing
class TableParser(SGMLParser):
    def reset(self):

        #Main table reconstruction dictionary:  table_level : [rows]
        self.tables = {}

        #Row tracking list, [td element,]
        self.curr_row = None

        #Object that track the current table
        self.last_table_level = 0
        self.seed = 1
        self.table_level = []

        self.capture_text = False

        #Parental reset
        SGMLParser.reset(self)
        
    #Table element recognition        
    def start_table(self, attrs):
        if len(self.table_level) == self.last_table_level: self.seed = 1
        self.table_level.append(self.seed)
        self.last_table_level = len(self.table_level)
        self.tables[tuple(self.table_level)] = []
        
    def end_table(self):
        self.seed = self.table_level[-1] + 1
        self.table_level.pop(-1)
        
    #Row element recognition
    def start_tr(self, attrs):
        self.curr_row = []
        self.tables[tuple(self.table_level)].append(self.curr_row)
                
    def end_tr(self): self.curr_row = None
    #Column element recognition
    def start_td(self, attrs):
        if self.curr_row != None: self.capture_text=True
        
    def end_td(self): self.capture_text = False
    def start_th(self, attrs):
        if self.curr_row != None: self.capture_text = True

    def end_th(self): self.capture_text = False        
    #Plain data handling
    def handle_data(self, text):
        if self.capture_text==True: self.curr_row.append(text)


#Event log structure pp'ers
def plainTextReport(reportDb): #this is a model for how we would build an xml pp'er for CTRA_xml uploading the raids/events automatically to a eqDKP site (if we get the mod installed)
    "Generates a plain text report"
    #reportDb = {run_name : {event_title:event_info},
    
    #Calculate a sensible file name
    now = datetime.datetime.now()
    filename = 'BrownieHelperReport_' + str(now.month) + '-' + str(now.day) + "-" + str(now.year)[-2:] + '.txt'
    fs = open(os.path.join(os.getcwd(), filename),'w')
    
    for runName, runInfo in reportDb.items():
        fs.write("-- " + runName + " --\n\n")
        
        #Bin events by general type category
        eventTypes = [
            ("Reward Events", lambda eventInfo: eventInfo['event'] in reward_events),
            ("Auction Events", lambda eventInfo: eventInfo['event'] in auction_events),
            ("Point Events", lambda eventInfo: eventInfo['event'] in point_events),
            ("Other Events", lambda eventInfo: eventInfo['event'] not in reward_events and eventInfo['event'] not in auction_events and eventInfo['event'] not in point_events)
            ]
        eventMap = dict([(k,[]) for k,v in eventTypes])
        for eventTitle,eventInfo in runInfo.items():
            for eventType,checker in eventTypes:
                if checker(eventInfo): eventMap[eventType].append(eventInfo)
                
        #Pp the events by category
        for eventType,c in eventTypes:
            
            #If this category isn't empty
            if eventMap[eventType]:
                eventMap[eventType].sort(lambda eventInfoA,eventInfoB: cmp(eventInfoA['gametime'],eventInfoB['gametime']))
                fs.write("*"+eventType+"*\n\n")
                for eventInfo in eventMap[eventType]:
                    fs.write("Event: " + eventInfo['event'] + "\n")
                    fs.write("Time: " + eventInfo['gametime'] + "\n")
                    if eventInfo.has_key('player'): fs.write("Player: " + eventInfo['player'] + "\n")
                    if eventInfo.has_key('players'): fs.write("Players: " + "\n" + "\n".join(eventInfo['players']) + "\n")
                    if eventInfo.has_key('itemname'): fs.write("Item: " + eventInfo['itemname'] + "\n")
                    if eventInfo.has_key('pointcost'): fs.write("Pointcost: " + eventInfo['pointcost'] + "\n")
                    if eventInfo.has_key('reward'): fs.write("Reward: " + eventInfo['reward'] + "\n")
                    for k,v in eventInfo.items():
                        if k not in ('event','gametime','uploaded','rewardevent','player','players','itemname','pointcost','reward'):
                            if type(v)==list: v = "\n" + "\n".join(v)
                            try: fs.write(k+": " + v + "\n")
                            except: print k, v
                    fs.write("\n")
                fs.write("\n")
        
    fs.close()
    print "Saved plain text report to: ", filename
    
#Queries for different times of events we can find in the runDbs
base_events = ["Raid start time", "Time adjustment"]
reward_events = base_events[:] #This list will get filled in when the guild configuration is loaded
point_events = ['Adjusted points',]
auction_events = ['Auction won',]

#Main bits of logic
def loadSavedVariables(saved_variables_path):
    """
    Adds in a layer of logic for handling unparsable or missing saved variables
    file.  Calls parseSavedVariables if available and garuntees a return of a
    valid BrownieHelperTable structure.
    """
    
    if os.path.exists(saved_variables_path):
        if not os.path.exists(os.path.join(saved_variables_path, mod_name + ".lua")):
            print "Creating new saved variables file:  " + mod_name + ".lua"
            fs = open(os.path.join(saved_variables_path, mod_name + ".lua"), 'w')
            fs.write(db_name + " = {}")
            fs.close()
        saved_variables_path = os.path.join(saved_variables_path, mod_name + ".lua")
        
    else:
        print "The supplied SavedVariablesPath is invalid."
        sys.exit()
    
    return parseSavedVariables(saved_variables_path)

def parseSavedVariables(saved_variables_path):
    
    #Open and 'parse' the file by exec'ing as python dicts
    fs = open(saved_variables_path)
    txt = fs.read()
    fs.close()
    
    print "Parsing SavedVariables file..."
    parser = LuaParser(txt)
    
    return LuaTable(parser.globalTable[db_name])

def updatePointsDb(realm_string, BrownieHelperTable, url_map):
    
    print "Updating local points for: ", realm_string
    
    for pool_name, url in url_map.items():
        print "Downloading & updating: ", pool_name
        
        #Retrieve & parse the page
        f = urllib.urlopen(url)
        page = f.read()
        p = TableParser()
        p.feed(page)
        data = p.tables[(2,)][2:-1]
        
        #Construct the lua table for points
        new_table = LuaTable()
        for line in data: new_table[line[0]] = line[-1] #Name is first, current points in the last column
        BrownieHelperTable['realms'][realm_string]['pointsDb'][pool_name] = new_table
        
    #Record a datetime stamp (in UTC) for this update
    t = datetime.datetime.utcnow()
    BrownieHelperTable['realms'][realm_string]["lastExternalUpdate"] = str(t.month) + "/" + str(t.day) + "/" + str(t.year)[-2:] + " " + str(t.hour) + ":" + str(t.minute)
    
def updateConfiguration(BrownieHelperTable, realm_string, tables):
    
    print "Updating configuration:"
    
    for name, table in tables.items():
        print name
        BrownieHelperTable['realms'][realm_string][name] = LuaTable(table)
    
def generateReports(realm_string, BrownieHelperTable, auto_check = True):
    print "Generating reports for events which have not been uploaded...", (auto_check and "(and marking them as uploaded)" or "")
    
    #Compile reportDb = {run_name : {event_title:event_info}, } for all unchecked events
    reportDb = {}
    for runName, runInfo in BrownieHelperTable['realms'][realm_string]['runDb'].items():
        
        events = {}
        for eventTitle, eventInfo in runInfo.get('events',{}).items():
            
            #If this is an unchecked event
            if not eventInfo['uploaded']:
                
                #Parse any value lists
                for k,v in eventInfo.items():
                    if type(v)==str and k!='description' and ", " in v: eventInfo[k] = v.split(", ")
                    
                #If we're autochecking -- check the event
                if auto_check: eventInfo['uploaded'] = True
                
                #Add this to the list of events
                events[eventTitle] = eventInfo
            
        if events: reportDb[runName] = events
        
    if reportDb:
        for report in reports: report(reportDb)

reports = [plainTextReport,]

if __name__ == '__main__':

    #Parse the local configuration file
    conf_parser = ConfigParser()
    conf_parser.optionxform = str #stop loosing case sensitivity
    print "Loading local config: ", "BrownieHelperLocal.cfg"
    conf_parser.read("BrownieHelperLocal.cfg")
    
    #See if there's a local config in the addons root
    rootConfigPath = os.path.split(os.path.split(os.getcwd())[0])[0]
    if os.path.exists(os.path.join(rootConfigPath,"BrownieHelperLocal.cfg")):
        print "Loading alternatvie local configuration: ", "BrownieHelperLocal.cfg (in addons root)"
        conf_parser.read(os.path.join(rootConfigPath,"BrownieHelperLocal.cfg"))
        
    #If a local configuration file is provided
    if conf_parser.has_option("GuildConfiguration","file"):
        print "Loading guild config: ", conf_parser.get("GuildConfiguration","file")
        conf_parser.read(conf_parser.get("GuildConfiguration","file"))
        
    #If a guild configuration url is provided
    elif conf_parser.has_option("GuildConfiguration","url"):
        print "Loading guild config: ", conf_parser.get("GuildConfiguration","url")
        conf_parser.readfp(urllib.urlopen(conf_parser.get("GuildConfiguration","url")))
        
    #Lets hope the guild configuration options are present in the local .cfg file
    else: print "No explicit guild configuration found.  This may end badly."
    
    #If no Wow SavedVariables path is provided, attempt to guess at one
    if conf_parser.has_option("SavedVariablesPath","path"): default_path = conf_parser.get("SavedVariablesPath","path")
    else:
        print "Guessing saved variables path... (may fail if you have more than one account)"
        
        currDir = os.getcwd()
        #We need to back track /interface/addons/browniehelper/script
        base_account_path = os.path.join(os.path.split(os.path.split(os.path.split(os.path.split(os.getcwd())[0])[0])[0])[0], "WTF", "Account")
        
        default_path=""
        if os.path.exists(base_account_path):
            
            dirs = [d for d in os.listdir(base_account_path) if os.path.isdir(os.path.join(base_account_path, d))]
            if len(dirs) and os.path.exists(os.path.join(base_account_path, dirs[0], "SavedVariables")): default_path = os.path.join(base_account_path, dirs[0], "SavedVariables")
    
    #Load the url map from the configuration info: url_map = {instance:url,}
    url_map = dict([(opt, conf_parser.get("PointSites",opt)) for opt in conf_parser.options("PointSites")])
    
    #Read in settings maps from the configuration file
    tables = {}
    for section in ["MinimumBids","LootTypes","Permissions","BossEvents","EventRewards","Options"]:
        new_map = tables[section] = {}
        for option in conf_parser.options(section):
            value = conf_parser.get(section,option)
            if "," in value:
                value = value.replace(", ",",").split(",")
                if value[-1]=="": del value[-1]
            if value!='': new_map[option] = value
    
    #Use the boss events to update the reward events list
    for bosses in tables['BossEvents'].values():
        for boss in bosses: reward_events.append(boss)
        
    #Create a new entry in tables that appends the built in events to boss events
    Events = tables["Events"] = {}
    for name,lst in tables["BossEvents"].items():
        Events[name] = base_events + lst
    
    #setup the command line options
    opt_parser = OptionParser(version="%prog " + __version__)
    opt_parser.add_option("-p", "--path", action="store", type="string", dest="path", metavar="PATH", default=default_path, help="Directory path to your account-specific SavedVariables directory [default: %default]")
    
    opt_parser.add_option("-r", "--realm", action="store", type="string", dest="realm", default=conf_parser.get("Server","realm"), metavar="REALM_NAME", help = "The name of the realm profile which the script should work with [default: %default]")
    opt_parser.add_option("-f", "--faction", action="store", type="string", dest="faction", default=conf_parser.get("Server","faction"), metavar="FACTION", help="The name of the faction profile which the script should work with [default: %default]")
    
    opt_parser.add_option("-u", "--no_update", action="store_false", dest="update", default=True, help="Skips updating the local saved variables file by downloading point totals from the eqDKP website")
    opt_parser.add_option("-m", "--no_report", action="store_false", dest="makereport", default=True, help="Skips generating reports for all un-uploaded events found in the saved variables file")
    opt_parser.add_option("-c", "--no_check", action="store_false", dest="auto_check", default=True, help="Skips automatically checking all reported events")
    
    #get the options we were passed
    (options, args) = opt_parser.parse_args()
    
    #Munge the realm string
    realm_string = options.realm + " - " + options.faction
    
    #Parse the saved variables file and make sure the mapping is complete-ish
    BrownieHelperTable=loadSavedVariables(options.path)
    if not BrownieHelperTable.has_key('realms'): BrownieHelperTable['realms'] = LuaTable()
    if not BrownieHelperTable['realms'].has_key(realm_string): BrownieHelperTable['realms'][realm_string] = LuaTable()
    if not BrownieHelperTable['realms'][realm_string].has_key('pointsDb'): BrownieHelperTable['realms'][realm_string]['pointsDb'] = LuaTable()
    if not BrownieHelperTable['realms'][realm_string].has_key('runDb'): BrownieHelperTable['realms'][realm_string]['runDb'] = LuaTable()
    updateConfiguration(BrownieHelperTable=BrownieHelperTable, realm_string=realm_string, tables=tables)
    
    #Do the work we've been asked to
    if options.update: updatePointsDb(realm_string=realm_string, BrownieHelperTable=BrownieHelperTable, url_map=url_map)
    if options.makereport: generateReports(realm_string=realm_string, BrownieHelperTable=BrownieHelperTable, auto_check=options.auto_check)
    
    print "Rewriting saved variables file..."
    fs = open(os.path.join(options.path, mod_name + ".lua") , 'w')
    fs.write(db_name + " = " + str(BrownieHelperTable))
    fs.close()
    
    #And we're done.
    print "Done."

